Add large-payload blob auto-purge (opt-in singleton job, worker/SDK side) - #758
Add large-payload blob auto-purge (opt-in singleton job, worker/SDK side)#758wangbill (YunchuWang) wants to merge 28 commits into
Conversation
82ae04d to
0ac2dc3
Compare
0752610 to
cab0e9a
Compare
cab0e9a to
c05b15a
Compare
Large orchestration payloads are externalized to Azure Blob Storage as `blob:v1:<container>:<blobName>` tokens. The DTS backend stores those tokens but cannot delete the backing blobs (it has no storage credentials) — only this SDK can. This adds an opt-in, whole-scheduler singleton durable entity + orchestration job (mirroring src/ExportHistory) that drains payload rows the backend has soft-deleted and deletes their blobs, then acks so the backend can hard-delete the rows. Design: - PayloadStore.DeleteAsync is virtual (default throws NotSupportedException so it is non-breaking for existing external subclasses); BlobPayloadStore overrides it to decode the token and call DeleteIfExistsAsync (idempotent). - BlobPurgeJob (TaskEntity singleton): Create is a no-op when already Active so racing client processes don't disturb the running job; Run starts a fixed-id orchestrator. - BlobPurgeJobOrchestrator (perpetual): fetch a batch of tombstones, delete the blobs with capped parallelism, ack the successful deletions (failed tokens stay tombstoned to retry), idle on a timer when empty, ContinueAsNew periodically. - ExecuteBlobPurgeJobOperationOrchestrator bridges client -> entity. - Two new unary RPCs on TaskHubSidecarService: GetTombstonedPayloads / AckPurgedPayloads (authoritative proto follow-up: microsoft/durabletask-protobuf#76). - LargePayloadStorageOptions gains AutoPurge (opt-in, default false) and PayloadPurgeBatchSize (default 500). - Client-side BlobPurgeJobStarter (IHostedService) ensures the singleton job when AutoPurge is enabled, without blocking host startup. Worker always registers the entity/orchestrators/activities so a client-enabled job has something to run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
c05b15a to
306d19f
Compare
…er simplification - Drop the `Dto` suffix now that the payload records are first-class public types in `Microsoft.DurableTask.Client` (`TombstonedPayload`, `PayloadPurgeAck`). - Collapse the magic `500` batch-size literal into a single `BlobPurgeConstants.DefaultBatchSize` used everywhere. - Rename `BlobPurgeJobStatus.Stopped` -> `Pending` (still the zero value) and remove the dead `Failed` member (nothing ever set it; the job self-heals). - Make the perpetual orchestrator self-heal: wrap each cycle in try/catch so a transient backend/entity/activity failure logs, backs off, and continues instead of failing the orchestration and killing the eternal loop. - Ack poison tokens: `DeleteExternalBlobActivity` now returns a three-way `BlobDeleteResult` (Deleted/Discarded/Retry). Malformed tokens are discarded and acked so the backend can clear the stuck row instead of re-streaming it forever; transient failures stay tombstoned to retry. - Replace the single-value `BlobPurgeJobCreationOptions` record with a plain `int` on `BlobPurgeJob.Create`. - Guard the client fetch RPC: `GetTombstonedPayloadsAsync` throws `ArgumentOutOfRangeException` unless `0 < limit < 1000`. - Simplify `BlobPurgeJobStarter` to a fixed-instance-id fire-once: drop the entity-active pre-check and schedule the Create bridge once with a fixed instance id, retrying only until the backend is reachable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 29 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Extensions/AzureBlobPayloads/AutoPurge/Entity/BlobPurgeJob.cs:36
purgeBatchSizeis stored verbatim into entity state without validation. If a caller invokesBlobPurgeJob.Createwith 0/negative or >1000, the orchestrator will later fail when calling GetTombstonedPayloads (the gRPC client enforces 1..1000), causing the job to back off and loop forever without making progress. Consider validating the range here (or coercing to a safe default) even if the starter path uses validated options, since the entity operation is callable independently.
this.State.Status = BlobPurgeJobStatus.Active;
this.State.PurgeBatchSize = purgeBatchSize;
this.State.CreatedAt ??= DateTimeOffset.UtcNow;
this.State.LastModifiedAt = DateTimeOffset.UtcNow;
this.State.LastError = null;
src/Extensions/AzureBlobPayloads/AutoPurge/Client/BlobPurgeJobStarter.cs:74
- The auto-purge capability gate is currently
this.store is not BlobPayloadStore, which assumes only BlobPayloadStore can delete payloads. SincePayloadStore.DeleteAsyncwas intentionally added as a virtual extensibility point, a custom PayloadStore could support deletion without being BlobPayloadStore. Consider gating on whetherDeleteAsyncis overridden (capability) rather than the concrete store type (implementation), so delete-capable custom stores can still opt in.
if (this.store is not BlobPayloadStore)
{
this.logger.BlobPurgeStoreCannotDelete(this.store.GetType().FullName);
return Task.CompletedTask;
}
Replaces the earlier draft shape with the finalized contract and dispositions. Wire contract (orchestrator_service.proto): drops GetTombstonedPayloads / AckPurgedPayloads and their messages, and splices in the canonical block verbatim -- GetLargePayloadTombstones / ReportLargePayloadPurgeResults, the LargePayloadPurgeDisposition and LargePayloadPurgeReason enums, and large_payload_auto_purge_enabled = 12 on GetWorkItemsRequest. Dispositions: removes Discarded entirely. It was success-shaped and destroyed evidence. Every attempt now returns a disposition plus a stable reason code. v1 tokens and malformed v2 bodies are Quarantined; unknown version prefixes, unreachable accounts, and authorization failures are Retry. Splitting the store's three ArgumentException cases is done with a token prefix gate in the activity rather than new exception types, which required widening TokenPrefixV2 to internal. Ownership marker: uploads now stamp managed_by=dts as blob metadata on both the compressed and uncompressed write paths, and the delete path reads the blob's properties and passes that read's ETag as an If-Match on the delete, so check-and-delete is atomic without a lease. A blob without the marker is left untouched and reported DELETED / BLOB_NOT_STORE_OWNED. Metadata rather than index tags: tags are unsupported on ADLS Gen2 and would require Storage Blob Data Owner. Orchestration: the batch is now always reported, since the backend owns retry scheduling and needs to hear about failures to defer a row. Backoff is preserved for the all-retry case so a storage outage cannot become a tight refetch loop. Progress counts only Deleted rows; counting quarantined rows as purged would reintroduce the success-shaped reporting this change removes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Extensions/AzureBlobPayloads/AutoPurge/Orchestrations/BlobPurgeJobOrchestrator.cs:55
processedCyclesis incremented before the continue-as-new check, but the condition uses>; this causes the orchestrator toContinueAsNewafter 6 cycles whenContinueAsNewFrequencyis 5. This contradicts the constant’s intent and increases history size more than necessary.
processedCycles++;
if (processedCycles > ContinueAsNewFrequency)
{
context.ContinueAsNew(new BlobPurgeJobRunRequest(input.JobEntityId, batchSize, ProcessedCycles: 0));
return null!;
src/Grpc/orchestrator_service.proto:799
- PR description and the embedded gRPC contract snippet describe
GetTombstonedPayloads/AckPurgedPayloads, but this proto definesGetLargePayloadTombstones/ReportLargePayloadPurgeResults. Before merging (and before syncing from the upstream protobuf repo), please reconcile the RPC/message naming to avoid a breaking drift between the SDK and the authoritative proto.
// Returns a bounded, deterministically ordered batch of due large-payload tombstones whose
// external blobs the worker must delete. Scoped to the caller's authenticated task hub.
// Only rows that are pending and whose next attempt time has arrived are returned; a row stays
// pending until its outcome is reported, so this is safe under retries and duplicate callers.
rpc GetLargePayloadTombstones(GetLargePayloadTombstonesRequest) returns (GetLargePayloadTombstonesResponse);
// Reports the outcome of each attempted blob deletion. The backend owns retry scheduling: it
// deletes rows reported as DELETED, reschedules RETRY with a reason-appropriate next attempt,
// and moves QUARANTINED rows out of the active fetch while preserving their evidence.
rpc ReportLargePayloadPurgeResults(ReportLargePayloadPurgeResultsRequest) returns (ReportLargePayloadPurgeResultsResponse);
| foreach (LargePayloadPurgeResult result in results) | ||
| { | ||
| if (result.Disposition == disposition) | ||
| { | ||
| count++; | ||
| } | ||
| } |
| foreach (KeyValuePair<string, string> entry in metadata) | ||
| { | ||
| if (string.Equals(entry.Key, OwnershipMarkerName, StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| return string.Equals(entry.Value, OwnershipMarkerValue, StringComparison.Ordinal); | ||
| } | ||
| } |
Pre-review polish only; no behaviour change. CS8603 in DeleteExternalBlobActivity.SanitizeErrorCode fired on netstandard2.0 only, where string.IsNullOrEmpty carries no [NotNullWhen(false)] annotation so flow analysis cannot prove the else branch non-null. Replaced with a constant pattern, which the compiler analyses itself and so behaves identically on every target framework. CA1001 in BlobPurgeJobStarter: the type owned a CancellationTokenSource but was not disposable. The source is deliberately disposed in Dispose rather than in StopAsync -- StopAsync stops waiting as soon as the host shutdown token fires, so the ensure task may still hold the token, and disposing there would fault that still-running task with ObjectDisposedException. The container disposes singletons after every StopAsync has returned, which is the safe point. SA1600: documented the BlobPurgeJobStarter constructor. CA1873 on BlobPurgeJobOrchestrator is deliberately left as-is: it occurs 77 times across this repo, so unguarded log calls are the established local convention and changing one new file would make it inconsistent with its neighbours for no benefit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The managed-to-proto enum casts in ReportLargePayloadPurgeResultsAsync are safe only because no enum crosses the wire inbound on this feature: the SDK casts values it defined itself, so it can never receive an unknown value and silently reinterpret it. That invariant held by the shape of the contract rather than by construction, and nothing in the code stated it. Adding an enum to an inbound type would create exactly the hazard the casts avoid today -- a newer backend sending a value this SDK does not know, mapped by raw numeric cast onto a valid-but-wrong member -- and it would compile silently, mis-dispositioning rows in production rather than failing a build. Asserted by reflection over the inbound types in the existing parity test file, which already exists to protect this mapping. The casts are unchanged: they are correct today, and an explicit switch would add branching for a hazard that does not currently exist on this path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/Extensions/AzureBlobPayloads/AutoPurge/Entity/BlobPurgeJob.cs:28
- BlobPurgeJob.Create stores purgeBatchSize verbatim without validation. Because the entity can be invoked via the public bridge orchestrator, an invalid value (e.g., 0) can be persisted and later cause GetLargePayloadTombstonesAsync(limit) to throw (limit must be 1..1000), resulting in a perpetual error/backoff loop.
public void Create(TaskEntityContext context, int purgeBatchSize)
{
if (this.State.Status == BlobPurgeJobStatus.Active)
{
logger.BlobPurgeJobAlreadyRunning(context.Id.Key);
src/Extensions/AzureBlobPayloads/AutoPurge/Orchestrations/BlobPurgeJobOrchestrator.cs:56
- ContinueAsNewFrequency is documented/used as "every 5 cycles", but the condition
processedCycles > ContinueAsNewFrequencytriggers on the 6th cycle (processedCycles is incremented before the check). This keeps more history than intended.
processedCycles++;
if (processedCycles > ContinueAsNewFrequency)
{
context.ContinueAsNew(new BlobPurgeJobRunRequest(input.JobEntityId, batchSize, ProcessedCycles: 0));
return null!;
}
src/Grpc/orchestrator_service.proto:799
- The PR description's "gRPC contract" section describes unary RPCs named
GetTombstonedPayloads/AckPurgedPayloadswith different message shapes, but the vendored proto here definesGetLargePayloadTombstonesandReportLargePayloadPurgeResults(with additional disposition/reason enums). This makes it hard to reconcile the intended wire contract and increases the risk of incompatibility with the upstream protobuf PR.
// Returns a bounded, deterministically ordered batch of due large-payload tombstones whose
// external blobs the worker must delete. Scoped to the caller's authenticated task hub.
// Only rows that are pending and whose next attempt time has arrived are returned; a row stays
// pending until its outcome is reported, so this is safe under retries and duplicate callers.
rpc GetLargePayloadTombstones(GetLargePayloadTombstonesRequest) returns (GetLargePayloadTombstonesResponse);
// Reports the outcome of each attempted blob deletion. The backend owns retry scheduling: it
// deletes rows reported as DELETED, reschedules RETRY with a reason-appropriate next attempt,
// and moves QUARANTINED rows out of the active fetch while preserving their evidence.
rpc ReportLargePayloadPurgeResults(ReportLargePayloadPurgeResultsRequest) returns (ReportLargePayloadPurgeResultsResponse);
CA1859: CreateOwnershipMetadata returned IDictionary<string, string> while always constructing a Dictionary. The method is private static with two call sites, both assigning to BlobOpenWriteOptions.Metadata (IDictionary<string, string>), so returning the concrete type is implicitly compatible and lets the JIT devirtualize the indexer. SA1117: split the ArgumentOutOfRangeException arguments one per line. CA1873 in BlobPurgeJobOrchestrator is left deliberately: unguarded logging is the established convention here (77 occurrences solution-wide), so guarding one new file would make it inconsistent with its neighbours. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b69ecb19-b596-4e46-bb44-12ce571ec31f
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/Extensions/AzureBlobPayloads/AutoPurge/Entity/BlobPurgeJob.cs:35
- BlobPurgeJob.Create stores
purgeBatchSizeverbatim into entity state. If a client ever calls Create with 0/negative (or an excessively large value), the orchestrator will pass it to GetLargePayloadTombstonesActivity/GrpcDurableTaskClient.GetLargePayloadTombstonesAsync, which throws forlimit <= 0 || limit > 1000(GrpcDurableTaskClient.cs:631-635). That would put the job into an endless backoff loop. Consider validating/clampingpurgeBatchSizehere to keep the entity from entering an unrecoverable configuration state.
this.State.Status = BlobPurgeJobStatus.Active;
this.State.PurgeBatchSize = purgeBatchSize;
this.State.CreatedAt ??= DateTimeOffset.UtcNow;
this.State.LastModifiedAt = DateTimeOffset.UtcNow;
src/Extensions/AzureBlobPayloads/AutoPurge/Client/BlobPurgeJobStarter.cs:82
- BlobPurgeJobStarter gates auto-purge on
store is BlobPayloadStore, butPayloadStore.DeleteAsyncwas introduced as a virtual extensibility point. This type-check prevents any custom PayloadStore that overrides DeleteAsync (and can safely delete its own payload objects) from enabling auto-purge, even though the API suggests it should be supported. Consider gating on whether DeleteAsync is actually overridden (or otherwise supported) rather than on the concrete type.
if (this.store is not BlobPayloadStore)
{
this.logger.BlobPurgeStoreCannotDelete(this.store.GetType().FullName);
return Task.CompletedTask;
}
src/Extensions/AzureBlobPayloads/AutoPurge/Client/BlobPurgeJobStarter.cs:109
- StopAsync waits for either the ensure task or the host shutdown token, but it never observes
ensureTask's completion result. If the background task faults, the exception can go unobserved (raising UnobservedTaskException later) and makes debugging harder. Consider awaiting the task when it completes (swallowing exceptions as intended) to ensure faults are observed.
Task? pending = this.ensureTask;
if (pending is not null)
{
// The ensure loop observes cancellation and returns promptly; swallow any faulted/cancelled result.
await Task.WhenAny(pending, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false);
src/Grpc/orchestrator_service.proto:795
- The PR description documents new sidecar RPC names
GetTombstonedPayloads/AckPurgedPayloads, but the vendored proto definesGetLargePayloadTombstones/ReportLargePayloadPurgeResults(and the client APIs follow those names). Please update the PR description (or rename the RPCs) so the documented contract matches the code.
rpc GetLargePayloadTombstones(GetLargePayloadTombstonesRequest) returns (GetLargePayloadTombstonesResponse);
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/Extensions/AzureBlobPayloads/AutoPurge/Models/BlobPurgeJobState.cs:34
BlobPurgeJobState.LastErroris documented as “the last error message”, but it’s never set anywhere in the auto-purge implementation (only cleared on Create). This is misleading for anyone reading the entity state; either wire it up (e.g., set it on cycle failures) or update the docs to reflect that it’s currently unused/reserved.
/// <summary>
/// Gets or sets the last error message, if any.
/// </summary>
public string? LastError { get; set; }
The backend never branches on reason - it acts on disposition alone - so reason is diagnostics only, and its granularity should match the number of distinct operator responses rather than the number of distinct causes. storageErrorCode already carries the specific storage status, so four of the old values encoded the same fact twice. TRANSIENT_STORAGE_FAILURE, STORAGE_ACCOUNT_UNREACHABLE and STORAGE_AUTHORIZATION_FAILED collapse into STORAGE_FAILURE. MALFORMED_TOKEN, INVALID_STORAGE_REQUEST, LEGACY_V1_TOKEN and UNSUPPORTED_TOKEN_VERSION collapse into TOKEN_NOT_PURGEABLE. STORE_CANNOT_DELETE stays separate because storage is never contacted, so its storageErrorCode is empty. Dispositions are unchanged on every branch. TOKEN_NOT_PURGEABLE now spans both Quarantined and Retry - an unsupported version prefix stays Retry because an SDK upgrade resolves it - so each branch continues to state its disposition explicitly rather than deriving it from the reason, which would strand those rows permanently. Proto verified byte-identical to contract-canonical.proto across all three repos by the marker-based harness. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b69ecb19-b596-4e46-bb44-12ce571ec31f
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Extensions/AzureBlobPayloads/AutoPurge/Orchestrations/BlobPurgeJobOrchestrator.cs:47
PurgeBatchSizecan be persisted as 0 (see BlobPurgeJobTests) and is then passed through to GetLargePayloadTombstonesActivity, which calls DurableTaskClient.GetLargePayloadTombstonesAsync(limit). The gRPC client throws ArgumentOutOfRangeException when limit <= 0, causing the orchestrator to repeatedly fail/back off and never make progress. Add a defensive clamp to a valid range before using the value.
string jobId = input.JobEntityId.Key;
int batchSize = input.PurgeBatchSize;
int processedCycles = input.ProcessedCycles;
src/Extensions/AzureBlobPayloads/AutoPurge/Client/BlobPurgeJobStarter.cs:108
- StopAsync waits for the background ensure task but never observes its completion/exception. If EnsureJobAsync ever faults unexpectedly, the exception may surface later as an UnobservedTaskException. After WhenAny returns, await the task (inside a try/catch) when it completed to observe and swallow the result as intended.
Task? pending = this.ensureTask;
if (pending is not null)
{
// The ensure loop observes cancellation and returns promptly; swallow any faulted/cancelled result.
await Task.WhenAny(pending, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false);
Comment-only. No enum value, field number, disposition, or code path changes; a non-comment-line filter over the full diff of both files returns empty. Block 3 of orchestrator_service.proto is re-spliced verbatim from the canonical contract. The "--- Reported with DELETED / RETRY / QUARANTINED ---" banners are removed: they asserted a 1:1 reason-to-disposition mapping that does not hold, because TOKEN_NOT_PURGEABLE is deliberately reported with two dispositions (QUARANTINED for a v1/malformed/invalid-request token, RETRY for an unrecognized version prefix that a newer worker can read). The disposition now lives in each value's own comment, and the enum header states outright that reason and disposition are orthogonal and that no mapping between them may be asserted. LargePayloadPurgeReason XML docs are rewritten to mirror the new canonical prose, so the managed surface no longer describes the superseded grouping. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b69ecb19-b596-4e46-bb44-12ce571ec31f
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Extensions/AzureBlobPayloads/AutoPurge/Entity/BlobPurgeJob.cs:36
- BlobPurgeJob.Create currently accepts any purgeBatchSize (including 0/negative), stores it, and signals the purge orchestrator. If a client ever creates the job with an invalid batch size, the orchestrator will later call GetLargePayloadTombstonesActivity with that value, and GrpcDurableTaskClient.GetLargePayloadTombstonesAsync throws for limit <= 0, causing the job to spin in the error/backoff loop. Validate the batch size at the entity boundary to keep the job from entering an unrecoverable misconfigured state.
public void Create(TaskEntityContext context, int purgeBatchSize)
{
if (this.State.Status == BlobPurgeJobStatus.Active)
{
logger.BlobPurgeJobAlreadyRunning(context.Id.Key);
return;
}
this.State.Status = BlobPurgeJobStatus.Active;
this.State.PurgeBatchSize = purgeBatchSize;
this.State.CreatedAt ??= DateTimeOffset.UtcNow;
this.State.LastModifiedAt = DateTimeOffset.UtcNow;
this.State.LastError = null;
test/Extensions/AzureBlobPayloads.Tests/AutoPurge/BlobPurgeJobTests.cs:78
- This test currently asserts that BlobPurgeJob.Create accepts a batch size of 0. If Create is callable via the client-to-entity bridge, persisting an invalid batch size can wedge the job (the gRPC client rejects limit <= 0). Update the test to assert the expected validation behavior instead.
[Fact]
public async Task Create_StoresBatchSizeVerbatim_WithoutCoercion()
{
// Arrange - the batch size is validated once at specification (LargePayloadStorageOptions), so the
// entity trusts its input and performs no coercion of its own. A zero here is stored as-is, proving
// the previous non-positive-to-default fallback was removed.
TestEntityOperation operation = new(
nameof(BlobPurgeJob.Create),
new TestEntityState(null),
0);
// Act
await this.job.RunAsync(operation);
// Assert
BlobPurgeJobState state = Assert.IsType<BlobPurgeJobState>(
operation.State.GetState(typeof(BlobPurgeJobState)));
state.PurgeBatchSize.Should().Be(0);
}
Both fields were verified write-only across the whole system: the backend persists them and nothing reads either one - no SELECT, no WHERE, no API, no alert. Disposition alone drives backend behavior, so the enum was a lossy copy of information that already lives in worker telemetry. Deleting reason also deletes the hazard it created. TOKEN_NOT_PURGEABLE spanned both Retry and Quarantined, so any consumer deriving disposition from reason would have silently stranded rows an SDK upgrade would fix. All 12 activity branches keep their disposition unchanged. Verified branch by branch against the pre-change file rather than by inspection: the unknown-prefix branch stays Retry, and the v1-prefix, malformed-token, and HTTP 400 branches stay Quarantined. Failure detail is now logged rather than sent over the wire. Design section 7 forbids logging raw exceptions, so each classification site logs a bounded cause literal instead. Logging at the classification site rather than at the call site restores the full 11-way granularity the 7-value enum had lost: 401/403, 5xx, and an unreachable account are now distinct in telemetry where the enum had collapsed all three into STORAGE_FAILURE. Two deliberate deviations from the request, both reported: 1. LargePayloadPurgeEnumParityTests.cs is kept, not deleted. It always tested two enums, and the disposition cast in GrpcDurableTaskClient survives - it is the cast that decides whether a row is deleted or quarantined. Only the reason parity fact is removed. 2. This is not the last outbound enum cast. LargePayloadPurgeResult still carries Disposition, so the no-inbound-enum invariant test still guards a live cast and is not vacuous. CA1873 at BlobPurgeJobOrchestrator.cs:66 remains deliberately unfixed: unguarded logging is the established convention here, 77 times solution-wide. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b69ecb19-b596-4e46-bb44-12ce571ec31f
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Extensions/AzureBlobPayloads/AutoPurge/Entity/BlobPurgeJob.cs:28
- BlobPurgeJob.Create stores purgeBatchSize verbatim with no range validation. If an invalid value (<=0 or > MaxBatchSize) is ever passed (e.g., direct entity call), the scheduled BlobPurgeJobOrchestrator will repeatedly fail when fetching tombstones (client validates 1..1000) and the job will back off forever. Validate the range at the entity boundary and fail fast with ArgumentOutOfRangeException.
public void Create(TaskEntityContext context, int purgeBatchSize)
{
if (this.State.Status == BlobPurgeJobStatus.Active)
{
logger.BlobPurgeJobAlreadyRunning(context.Id.Key);
src/Extensions/AzureBlobPayloads/AutoPurge/Activities/DeleteExternalBlobActivity.cs:64
- DeleteExternalBlobActivity claims it returns failures as a disposition rather than throwing, but RunAsync currently throws on empty input via Check.NotNullOrEmpty. Since the token comes from the backend, treating an empty token as a deterministic protocol failure and returning a Quarantined disposition keeps a single bad row from failing the whole batch and avoids an activity retry loop that never produces a per-row result.
public override async Task<BlobPurgeOutcome> RunAsync(TaskActivityContext context, string input)
{
Check.NotNullOrEmpty(input, nameof(input));
return await this.DeleteAsync(input);
Summary
Large orchestration payloads are externalized to Azure Blob Storage by the
AzureBlobPayloadsextension, with a token persisted in SQL instead of the payload bytes. When the orchestration is purged, DTS removes the SQL state but cannot delete the backing blob — it has no customer storage credentials. Only the worker does.This PR implements the worker/SDK side. The backend records a durable tombstone for each externalized payload whose orchestration state is gone; the worker fetches due tombstones, deletes the blobs, and reports the outcome of every row so the backend can resolve, reschedule, or quarantine it.
Companion changes: contract in microsoft/durabletask-protobuf#76, backend in AAPT-DTMB PR 16368738.
gRPC contract
Two unary RPCs on
TaskHubSidecarService(worker is the client). The vendoredsrc/Grpc/orchestrator_service.protois byte-identical to protobuf#76 — verified mechanically by exact-substring comparison, not by eye.LargePayloadTombstone { partitionId, instanceKey, payloadId, token, revision }LargePayloadPurgeResult { identity, revision, disposition }google.protobuf.BoolValue large_payload_auto_purge_enabled = 12on the existingGetWorkItemsRequest— no new handshake, and null means "no opinion".revisionis echoed back unmodified as a compare-and-swap guard, so duplicate or stale reports are no-ops without a per-row lease.Dispositions
Three dispositions, split on whether a failure can self-heal. There is no
Discarded: a success is reported explicitly asDeleted, and every failure is carried byRetryorQuarantined.DeletedRetryQuarantinedThe worker never computes a retry delay. It reports the failure and the backend owns scheduling and backoff. The orchestrator reports the whole batch unconditionally — including retryable rows — because the backend needs to hear about a failure in order to defer the row. If every row comes back
Retry(a storage outage), the cycle appliesErrorBackoffso an outage cannot become a tight refetch loop.dispositionis the entire outcome. An earlier revision also carriedreasonandstorageErrorCode; both were removed after verifying they were write-only end to end — the backend persists them and nothing reads either one. Failure detail is logged by the worker instead, at the classification site, which is strictly richer than the enum was: 401/403, 5xx, and an unreachable account are distinct in telemetry where the enum collapsed all three into one value.Blob ownership marker
A recognized token proves only that the text looks like one this store emits — the column is customer-writable. So ownership is recorded on the object itself:
UploadAsyncwrites fixed blob metadatamanaged_by=dts, and the worker re-reads the target's metadata immediately before deleting.If-Matchon the ETag from that same read, so a mid-flight overwrite fails the delete rather than destroying newer content.Deleted, so the tombstone is resolved rather than retried forever. This is an expected outcome, not a defect, and must not be quarantined. The worker logs it distinctly so a customer whose payloads are all self-authored is still visible in telemetry.The metadata name uses an underscore because Azure requires blob metadata names to be valid C# identifiers;
managed-bywould be rejected at upload.Only
blob:v2:tokens are auto-purged. Av1token reaching this path is an invariant violation (v1 is excluded at insertion) and is quarantined rather than deleted.Testing
Verified on a clean (
--no-incremental) build:dotnet build Microsoft.DurableTask.sln— 0 errorstest/Extensions/AzureBlobPayloads.Tests— 54 passedtest/Client/Grpc.Tests— 56 passedLargePayloadPurgeEnumParityTestspins proto↔managed parity forLargePayloadPurgeDispositionby value and name in both directions, and asserts that no inbound type exposes an enum.Dispositionis the one enum crossing the wire, and its numeric cast inGrpcDurableTaskClientis what decides whether a row is deleted or quarantined; that cast is safe only because enums travel outbound-only, and the test fails the build if a future change breaks that invariant.Notes / intentional deviations
CA1873atBlobPurgeJobOrchestrator.cs:66(unguarded logging). Kept deliberately for consistency with 77 existing instances across the solution. Blame-based attribution againstmainconfirms this is the only warning this branch introduces.PurgedCountcounts everyDeletedrow, including blobs skipped for lacking the ownership marker. Excluding them would pin the counter at 0 for a customer whose payloads are all self-authored, making a healthy draining job read as wedged; the worker log supplies the precision instead.PayloadStore.DeleteAsyncisvirtualwith a default that throwsNotSupportedException, so existing external subclasses are unaffected.BlobPurgeJob.Createis a no-op when alreadyActiveso racing client processes don't disturb a running job.BlobPurgeJobStarterimplementsIDisposablerather than disposing its CTS inStopAsync: that method returns on the host shutdown token while the ensure task may still be live, so disposing there would fault it with an unobservedObjectDisposedException.